Skip to content

feat(rate-limits): per-realm configurable auth rate-limit ceilings#103

Merged
windischb merged 1 commit into
developfrom
feat/configurable-auth-rate-limits
Jun 23, 2026
Merged

feat(rate-limits): per-realm configurable auth rate-limit ceilings#103
windischb merged 1 commit into
developfrom
feat/configurable-auth-rate-limits

Conversation

@windischb

Copy link
Copy Markdown
Contributor

What

The per-IP auth rate limiters were hardcoded ASP.NET policies. Raising a ceiling on a shared production IdP meant a modgud code change + redeploy — and changed it for every realm. Surfaced when AmZettel hit the native-otp limit during normal iterative deploy-and-verify testing.

This makes each ceiling configurable per realm (limit + window), defaults unchanged so a realm that never touches it behaves exactly as before. Reuses the ADR-0011 RealmSettings cascade — the same pattern DCR's per-realm rate limits already use.

Scope: the whole per-IP auth-limiter family — native-otp, magic-link, password-reset, email-otp, email-verification, passkey-begin, bootstrap.

How the live limiter reads per-realm config

The ASP.NET policy factories are synchronous and can't do the async settings lookup. So a thin middleware (AuthRateLimitResolutionMiddleware) resolves the realm's AuthRateLimits — after RealmMiddleware resolves the tenant, before UseRateLimiter — and stashes them on HttpContext.Items; the factories read the effective rule there, falling back to the shipped AuthRateLimitDefaults.

  • The realm slug + resolved limit are baked into the partition key, so each realm gets its own per-IP bucket and a config edit applies on the next request (stale limiter idles out).
  • A short cache (TTL 0 in Testing, ~10 s otherwise) keeps the limiter's cheap-rejection property under flood and makes config-change tests deterministic.

Changes

  • Domain: AuthRateLimitSettings + AuthRateLimitPolicy + AuthRateLimitDefaults; nullable AuthRateLimits on RealmSettings.
  • Application/service: read + update DTOs; patch (with 1..100000 / 1..1440 validation) + map.
  • Api: the middleware + AuthFixedWindow partition helper; the 7 per-IP policy factories resolve their ceiling per request (the oauth-token sliding/per-client policy is untouched).
  • Frontend: a "Rate Limits" tab in Realm Settings (7 policies × limit + window) + German i18n.
  • Tests: lowered limit throttles sooner; raised limit allows past the old default; the existing boundary test still passes (proves defaults unchanged). 8/8 green.
  • Docs: realm-settings.md → Rate Limits section; native-apps.md pointer.

Behaviour note (worth a look in review)

Partitioning changes from pure-per-IP-global to per-realm-per-IP (the limit is now realm config, so the bucket must be per-realm for it to mean anything). On a shared IdP this means one IP gets a separate bucket per realm — slightly more lenient cross-realm, but coherent with per-realm limits. Defaults and single-realm behaviour are unchanged.

Answers the AmZettel request (Atlas: requests-amzettel-native-otp-rate-limit-configurable).

🤖 Generated with Claude Code

The per-IP auth rate limiters (native-otp, magic-link, password-reset,
email-otp, email-verification, passkey-begin, bootstrap) were hardcoded ASP.NET
policies — the only way to raise a ceiling on a shared prod IdP was a code change
+ redeploy, which also changed it for every realm. Surfaced by AmZettel hitting
the native-otp limit during normal iterative testing.

Make each ceiling configurable per realm (limit + window), defaults UNCHANGED so
a realm that never touches it behaves exactly as before. Reuses ADR-0011's
RealmSettings cascade — the same pattern DCR's per-realm rate limits already use.

How the live limiter reads per-realm config: the ASP.NET policy factories are
synchronous and can't do the async settings lookup, so a thin middleware resolves
the realm's AuthRateLimits (after RealmMiddleware, before UseRateLimiter) and
stashes them on HttpContext.Items; the factories read the effective rule there,
falling back to the shipped defaults. The realm slug + resolved limit are baked
into the partition key so each realm gets its own per-IP bucket and a config edit
applies on the next request. A short cache (TTL 0 in Testing) keeps the limiter's
cheap-rejection property under flood.

- Domain: AuthRateLimitSettings + AuthRateLimitPolicy + AuthRateLimitDefaults;
  nullable AuthRateLimits section on RealmSettings.
- Application/service: read + update DTOs, patch (with validation) + map.
- Api: AuthRateLimitResolutionMiddleware + AuthFixedWindow partition helper;
  the 7 per-IP policy factories now resolve their ceiling per request.
- Frontend: a "Rate Limits" tab in Realm Settings (7 policies, limit + window) +
  German i18n.
- Tests: lowered limit throttles sooner, raised limit allows past the old
  default; the existing boundary test still passes (defaults unchanged).
- Docs: realm-settings.md "Rate Limits" section + native-apps.md pointer.

Answers the AmZettel request (Atlas: requests-amzettel-native-otp-rate-limit-configurable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@windischb windischb merged commit e47577c into develop Jun 23, 2026
8 checks passed
@windischb windischb deleted the feat/configurable-auth-rate-limits branch June 23, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant